forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import { setResponseHeader } from 'h3'
3import type { DocsResponse } from '#shared/types'
4import { assertValidPackageName, fetchLatestVersion } from '#shared/utils/npm'
5
6definePageMeta({
7 name: 'docs',
8 path: '/package-docs/:path+',
9 alias: ['/package/docs/:path+', '/docs/:path+'],
10})
11
12const route = useRoute('docs')
13const router = useRouter()
14
15const parsedRoute = computed(() => {
16 const segments = route.params.path?.filter(Boolean)
17 const vIndex = segments.indexOf('v')
18
19 if (vIndex === -1 || vIndex >= segments.length - 1) {
20 return {
21 packageName: segments.join('/'),
22 version: null as string | null,
23 }
24 }
25
26 return {
27 packageName: segments.slice(0, vIndex).join('/'),
28 version: segments.slice(vIndex + 1).join('/'),
29 }
30})
31
32const packageName = computed(() => parsedRoute.value.packageName)
33const requestedVersion = computed(() => parsedRoute.value.version)
34
35// Validate package name on server-side for early error detection
36if (import.meta.server && packageName.value) {
37 assertValidPackageName(packageName.value)
38}
39
40const { data: pkg } = usePackage(packageName)
41
42const latestVersion = computed(() => pkg.value?.['dist-tags']?.latest ?? null)
43
44if (import.meta.server && !requestedVersion.value && packageName.value) {
45 const app = useNuxtApp()
46 const version = await fetchLatestVersion(packageName.value)
47 if (version) {
48 setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache')
49 const pathSegments = [...packageName.value.split('/'), 'v', version]
50 app.runWithContext(() =>
51 navigateTo(
52 { name: 'docs', params: { path: pathSegments as [string, ...string[]] } },
53 { redirectCode: 302 },
54 ),
55 )
56 }
57}
58
59watch(
60 [requestedVersion, latestVersion, packageName],
61 ([version, latest, name]) => {
62 if (!version && latest && name) {
63 const pathSegments = [...name.split('/'), 'v', latest]
64 router.replace({ name: 'docs', params: { path: pathSegments as [string, ...string[]] } })
65 }
66 },
67 { immediate: true },
68)
69
70const resolvedVersion = computed(() => requestedVersion.value ?? latestVersion.value)
71
72const docsUrl = computed(() => {
73 if (!packageName.value || !resolvedVersion.value) return null
74 return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}`
75})
76
77const shouldFetch = computed(() => !!docsUrl.value)
78
79const { data: docsData, status: docsStatus } = useLazyFetch<DocsResponse>(
80 () => docsUrl.value ?? '',
81 {
82 watch: [docsUrl],
83 immediate: shouldFetch.value,
84 default: () => ({
85 package: packageName.value,
86 version: resolvedVersion.value ?? '',
87 html: '',
88 toc: null,
89 status: 'missing' as const,
90 message: 'Docs are not available for this version.',
91 }),
92 },
93)
94
95const pageTitle = computed(() => {
96 if (!packageName.value) return 'API Docs - npmx'
97 if (!resolvedVersion.value) return `${packageName.value} docs - npmx`
98 return `${packageName.value}@${resolvedVersion.value} docs - npmx`
99})
100
101useSeoMeta({
102 title: () => pageTitle.value,
103 ogTitle: () => pageTitle.value,
104 twitterTitle: () => pageTitle.value,
105 description: () => pkg.value?.license ?? '',
106 ogDescription: () => pkg.value?.license ?? '',
107 twitterDescription: () => pkg.value?.license ?? '',
108})
109
110defineOgImageComponent('Default', {
111 title: () => `${pkg.value?.name ?? 'Package'} - Docs`,
112 description: () => pkg.value?.license ?? '',
113 primaryColor: '#60a5fa',
114})
115
116const showLoading = computed(() => docsStatus.value === 'pending')
117const showEmptyState = computed(() => docsData.value?.status !== 'ok')
118</script>
119
120<template>
121 <div class="docs-page flex-1 flex flex-col">
122 <!-- Visually hidden h1 for accessibility -->
123 <h1 class="sr-only">{{ packageName }} API Documentation</h1>
124
125 <!-- Sticky header - positioned below AppHeader -->
126 <header
127 aria-label="Package documentation header"
128 class="docs-header sticky z-10 border-b border-border"
129 >
130 <div class="absolute inset-0 bg-bg/90 backdrop-blur" />
131 <div class="relative px-4 sm:px-6 lg:px-8 py-4 z-1">
132 <div class="flex items-center justify-between gap-4">
133 <div class="flex items-center gap-3 min-w-0">
134 <NuxtLink
135 v-if="packageName"
136 :to="packageRoute(packageName)"
137 class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate"
138 >
139 {{ packageName }}
140 </NuxtLink>
141 <VersionSelector
142 v-if="resolvedVersion && pkg?.versions && pkg?.['dist-tags']"
143 :package-name="packageName"
144 :current-version="resolvedVersion"
145 :versions="pkg.versions"
146 :dist-tags="pkg['dist-tags']"
147 :url-pattern="`/package-docs/${packageName}/v/{version}`"
148 />
149 <span v-else-if="resolvedVersion" class="text-fg-subtle font-mono text-sm shrink-0">
150 {{ resolvedVersion }}
151 </span>
152 </div>
153 <div class="flex items-center gap-3 shrink-0">
154 <span class="text-xs px-2 py-1 rounded badge-green border border-badge-green/50">
155 API Docs
156 </span>
157 </div>
158 </div>
159 </div>
160 </header>
161
162 <div class="flex" dir="ltr">
163 <!-- Sidebar TOC -->
164 <aside
165 v-if="docsData?.toc && !showEmptyState"
166 class="hidden lg:block w-64 xl:w-72 shrink-0 border-ie border-border"
167 >
168 <div class="docs-sidebar sticky overflow-y-auto p-4">
169 <h2 class="text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-4">
170 Contents
171 </h2>
172 <!-- eslint-disable vue/no-v-html -->
173 <div class="toc-content" v-html="docsData.toc" />
174 </div>
175 </aside>
176
177 <!-- Main content -->
178 <main class="flex-1 min-w-0">
179 <div v-if="showLoading" class="p-6 sm:p-8 lg:p-12 space-y-4">
180 <SkeletonBlock class="h-8 w-64 rounded" />
181 <SkeletonBlock class="h-4 w-full max-w-2xl rounded" />
182 <SkeletonBlock class="h-4 w-5/6 max-w-2xl rounded" />
183 <SkeletonBlock class="h-4 w-3/4 max-w-2xl rounded" />
184 </div>
185
186 <div v-else-if="showEmptyState" class="p-6 sm:p-8 lg:p-12">
187 <div class="max-w-xl rounded-lg border border-border bg-bg-muted p-6">
188 <h2 class="font-mono text-lg mb-2">{{ $t('package.docs.not_available') }}</h2>
189 <p class="text-fg-subtle text-sm">
190 {{ docsData?.message ?? $t('package.docs.not_available_detail') }}
191 </p>
192 <div class="flex gap-4 mt-4">
193 <NuxtLink
194 v-if="packageName"
195 :to="packageRoute(packageName)"
196 class="link-subtle font-mono text-sm"
197 >
198 View package
199 </NuxtLink>
200 </div>
201 </div>
202 </div>
203
204 <!-- eslint-disable vue/no-v-html -->
205 <div v-else class="docs-content p-6 sm:p-8 lg:p-12" v-html="docsData?.html" />
206 </main>
207 </div>
208 </div>
209</template>
210
211<style>
212/* Layout constants - must match AppHeader height */
213.docs-page {
214 --app-header-height: 57px;
215 --docs-header-height: 57px;
216 --combined-header-height: calc(var(--app-header-height) + var(--docs-header-height));
217}
218
219.docs-header {
220 top: var(--app-header-height);
221}
222
223.docs-sidebar {
224 top: var(--combined-header-height);
225 height: calc(100vh - var(--combined-header-height));
226}
227
228/* Table of contents styles */
229.toc-content ul {
230 @apply space-y-1;
231}
232
233.toc-content > ul > li {
234 @apply mb-4;
235}
236
237.toc-content > ul > li > a {
238 @apply text-sm font-medium text-fg-muted hover:text-fg;
239}
240
241.toc-content > ul > li > ul {
242 @apply mt-2 ps-3 border-is border-border/50;
243}
244
245.toc-content > ul > li > ul a {
246 @apply text-xs text-fg-subtle hover:text-fg block py-0.5 truncate;
247}
248
249/* Main docs content container - no max-width to use full space */
250.docs-content {
251 @apply max-w-none;
252}
253
254/* Section headings */
255.docs-content .docs-section {
256 @apply mb-16;
257}
258
259.docs-content .docs-section-title {
260 @apply text-lg font-semibold text-fg mb-8 pb-3 pt-4 border-b border-border sticky bg-bg z-[2];
261 top: var(--combined-header-height);
262}
263
264/* Individual symbol articles */
265.docs-content .docs-symbol {
266 @apply mb-10 pb-10 border-b border-border/30 last:border-0;
267}
268
269.docs-content .docs-symbol:target {
270 @apply scroll-mt-32;
271}
272
273.docs-content .docs-symbol:target .docs-symbol-header {
274 @apply bg-badge-yellow/10 -mx-3 px-3 py-1 rounded-md;
275}
276
277/* Symbol header (name + badges) */
278.docs-content .docs-symbol-header {
279 @apply flex items-center gap-3 mb-4 flex-wrap;
280}
281
282.docs-content .docs-anchor {
283 @apply text-fg-subtle/50 hover:text-fg-subtle transition-colors text-lg no-underline;
284}
285
286.docs-content .docs-symbol-name {
287 @apply font-mono text-lg font-semibold text-fg m-0;
288}
289
290/* Badges */
291.docs-content .docs-badge {
292 @apply text-xs px-2 py-0.5 rounded-full font-medium;
293}
294
295.docs-content .docs-badge--function {
296 @apply badge-blue;
297}
298.docs-content .docs-badge--class {
299 @apply badge-yellow;
300}
301.docs-content .docs-badge--interface {
302 @apply badge-green;
303}
304.docs-content .docs-badge--typeAlias {
305 @apply badge-indigo;
306}
307.docs-content .docs-badge--variable {
308 @apply badge-orange;
309}
310.docs-content .docs-badge--enum {
311 @apply badge-pink;
312}
313.docs-content .docs-badge--namespace {
314 @apply badge-cyan;
315}
316.docs-content .docs-badge--async {
317 @apply badge-purple;
318}
319
320/* Signature code block - now uses Shiki */
321.docs-content .docs-signature {
322 @apply mb-5;
323}
324
325.docs-content .docs-signature .shiki {
326 @apply text-sm bg-bg-muted/50 border border-border/50 p-4 rounded-lg;
327 white-space: pre-wrap;
328 word-break: break-word;
329}
330
331.docs-content .docs-signature .shiki code {
332 @apply text-sm;
333 white-space: pre-wrap;
334}
335
336/* Overload count badge */
337.docs-content .docs-overload-count {
338 @apply text-xs text-fg-subtle;
339}
340
341/* More overloads indicator */
342.docs-content .docs-more-overloads {
343 @apply text-xs text-fg-subtle italic mt-2 mb-0;
344}
345
346/* Description text */
347.docs-content .docs-description {
348 @apply text-sm text-fg-muted leading-relaxed mb-5;
349}
350
351/* Inline code in descriptions */
352.docs-content .docs-description code {
353 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono;
354}
355
356/*
357 * Fenced code blocks in descriptions use a subtle start-border style.
358 *
359 * Design rationale: We use two visual styles for code examples:
360 * 1. Boxed style (bg + border + padding) - for formal @example JSDoc tags
361 * and function signatures. These are intentional, structured sections.
362 * 2. Start-border style (blockquote-like) - for inline code in descriptions.
363 * These are illustrative/casual and shouldn't compete with the signature.
364 */
365.docs-content .docs-description .shiki {
366 @apply text-sm ps-4 py-3 my-4 border-is-2 border-border;
367 white-space: pre-wrap;
368 word-break: break-word;
369}
370
371.docs-content .docs-description .shiki code {
372 @apply text-sm bg-transparent p-0;
373 white-space: pre-wrap;
374}
375
376/* Deprecation warning */
377.docs-content .docs-deprecated {
378 @apply bg-badge-orange/20 border border-badge-orange rounded-lg p-4 mb-5;
379}
380
381.docs-content .docs-deprecated strong {
382 @apply text-badge-orange text-sm;
383}
384
385.docs-content .docs-deprecated-message {
386 @apply text-badge-orange text-sm mt-2;
387}
388
389.docs-content .docs-deprecated-message code {
390 @apply bg-badge-orange/20 text-badge-orange;
391}
392
393.docs-content .docs-deprecated-message .docs-link {
394 @apply text-badge-orange;
395}
396
397/* Parameters, Returns, Examples, See Also sections */
398.docs-content .docs-params,
399.docs-content .docs-returns,
400.docs-content .docs-examples,
401.docs-content .docs-see,
402.docs-content .docs-members {
403 @apply mb-5;
404}
405
406.docs-content .docs-params h4,
407.docs-content .docs-returns h4,
408.docs-content .docs-examples h4,
409.docs-content .docs-see h4,
410.docs-content .docs-members h4 {
411 @apply text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-3;
412}
413
414/* Definition lists for params/members */
415.docs-content dl {
416 @apply space-y-2;
417}
418
419.docs-content dt {
420 @apply font-mono text-sm text-fg-muted;
421}
422
423.docs-content dd {
424 @apply text-sm text-fg-subtle ms-4 mb-3;
425}
426
427/* Returns paragraph */
428.docs-content .docs-returns p {
429 @apply text-sm text-fg-muted m-0;
430}
431
432/* Example code blocks from @example JSDoc tags - boxed style (see design rationale above) */
433.docs-content .docs-examples .shiki {
434 @apply text-sm bg-bg-muted border border-border/50 p-4 rounded-lg overflow-x-auto mb-3;
435}
436
437.docs-content .docs-examples .shiki code {
438 @apply text-sm;
439}
440
441/* See also list */
442.docs-content .docs-see ul {
443 @apply list-disc list-inside text-sm text-fg-muted space-y-1;
444}
445
446.docs-content .docs-link {
447 @apply text-badge-blue hover:text-badge-blue/80 underline underline-offset-2;
448}
449
450/* Symbol cross-reference links */
451.docs-content .docs-symbol-link {
452 @apply text-badge-green hover:text-badge-green/80 underline underline-offset-2;
453}
454
455/* Unknown symbol references shown as code */
456.docs-content .docs-symbol-ref {
457 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono;
458}
459
460/* Inline code in descriptions */
461.docs-content .docs-inline-code {
462 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono;
463}
464
465/* Enum members */
466.docs-content .docs-enum-members {
467 @apply flex flex-wrap gap-2 list-none p-0;
468}
469
470.docs-content .docs-enum-members li {
471 @apply m-0;
472}
473
474.docs-content .docs-enum-members code {
475 @apply text-sm font-mono text-fg-muted bg-bg-muted px-2 py-1 rounded;
476}
477
478/* Members section (constructors, properties, methods) */
479.docs-content .docs-members pre {
480 @apply text-sm bg-bg-muted/50 border border-border/50 p-3 rounded-lg overflow-x-auto font-mono;
481}
482
483.docs-content .docs-members pre code {
484 @apply text-fg-muted;
485}
486
487.docs-content .docs-symbol-name,
488.docs-content .docs-members dl dd,
489.docs-content .docs-members dl dt code,
490.docs-content .docs-section .docs-symbol .docs-description {
491 word-break: break-all;
492}
493</style>